Skip to content

MCP: 2026-07-28 core, legacy MCP as a deletable edge (#1671, #1670) - #1672

Open
jlouvel wants to merge 3 commits into
3.0from
feat/mcp-dual-era-client
Open

jlouvel wants to merge 3 commits into
3.0from
feat/mcp-dual-era-client

Conversation

@jlouvel

@jlouvel jlouvel commented Nov 13, 2026

Copy link
Copy Markdown
Collaborator

Closes #1671 and #1670. Design memo: https://claude.ai/artifact/73QEwzKyg79YHVHjAyHdLd

Server (#1671)

  • Registrations, tool filters (new McpToolFilter) and interceptors use the SDK's modern types and run once per request, in either era.
  • Breaking (3.0 is at M1, no deprecation step): the 2025-11-25 registration lists are removed; getTools(), getResources(), getResourceTemplates(), getPrompts() take modern types; McpModernResource/McpModernResourceTemplate are renamed McpResource/McpResourceTemplate; dispatch() and setServerInfo() take modern types.
  • Legacy requests go through the McpLegacyEdge seam to internal.legacy.McpLegacyServer: initialize answered from server/discover (tools, resources, prompts only), ping empty result, notifications dropped, logging/setLevel and resources/subscribe return -32601, no session. Legacy clients get the final result of a streaming tool, and an error instead of input_required.
  • McpLegacyBoundaryTestCase enforces the three boundary rules by scanning sources. One allow-listed exception: the SDK's modern header decoder takes HeaderAccessor from a legacy package.

Client (#1670)

  • The SDK's legacy client and both legacy transports move to internal.legacy. Removed: getSdkClient(), StdioClientHelper.getTransport(), LEGACY_INPUT_KEY, the public RestletMcpClientTransport.
  • Auto mode probes server/discover and falls back to initialize on a non-modern error; the era is cached per endpoint.
  • listTools, callTool, readResource, getPrompt return modern types in both eras.
  • With a legacy server, the client declares no capabilities and answers server requests with -32601.
  • Opt-in legacy sessions: setLegacySessions(boolean), setLegacySessionId(String) (caller-owned, sent but never deleted), getLegacySessionId().

Not done

  • Over stdio, auto mode speaks 2025-11-25: there's no modern stdio client yet.
  • The server edge accepts only 2025-11-25 in initialize, as before.

Tests

./mvnw -B verify -P default,test -pl org.restlet.ext.mcp: 112 tests pass, coverage and SpotBugs checks met.

🤖 Generated with Claude Code

Registrations, tool filters and interceptors now use the SDK's modern
types. MCP 2025-11-25 traffic is translated at a legacy edge in
internal.legacy, behind the McpLegacyEdge seam, which exposes nothing
2026-07-28 deprecates or removes. An architecture test enforces the
boundary.

The client detects the server's era (auto mode), falls back to legacy
servers without capabilities, and keeps their session only on opt-in,
through setLegacySessions() or setLegacySessionId().

Closes #1671, closes #1670.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Legacy interceptor failures lose request IDs and can fail the stdio transport, while reply-handling, shutdown-timeout, and discovery-fallback defects also remain unresolved.

5 open issues
What changed in this PR

This PR moves the Restlet MCP extension to a modern SDK core, isolating legacy compatibility behind a removable edge. It also adds opt-in HTTP era detection and legacy session support.

Changes:

  • Unifies server registrations, filters, interceptors and common client results on modern SDK types.
  • Isolates legacy transports and translation, with restricted capabilities and optional sessions.
  • Updates integration tests, adds boundary checks and documents the API changes.
File Description
org.restlet.ext.mcp/​src/​test/​java/​org/​restlet/​ext/​mcp/​McpTestFeatures.java Adds shared modern test fixtures.
org.restlet.ext.mcp/​src/​test/​java/​org/​restlet/​ext/​mcp/​McpStdioTestCase.java Updates stdio tests for the legacy edge.
org.restlet.ext.mcp/​src/​test/​java/​org/​restlet/​ext/​mcp/​McpSessionSpikeTestCase.java Reuses the extracted session test host.
org.restlet.ext.mcp/​src/​test/​java/​org/​restlet/​ext/​mcp/​McpServerAdapterTestCase.java Tests shared policy and restricted legacy behavior.
org.restlet.ext.mcp/​src/​test/​java/​org/​restlet/​ext/​mcp/​McpModuleTestCase.java Exercises modern dispatch from a named module.
org.restlet.ext.mcp/​src/​test/​java/​org/​restlet/​ext/​mcp/​McpModernStdioTestCase.java Uses unified tool registration.
org.restlet.ext.mcp/​src/​test/​java/​org/​restlet/​ext/​mcp/​McpModernServerTestCase.java Updates registrations and tests legacy result translation.
org.restlet.ext.mcp/​src/​test/​java/​org/​restlet/​ext/​mcp/​McpModernClientTestCase.java Adapts client tests to the revised API.
org.restlet.ext.mcp/​src/​test/​java/​org/​restlet/​ext/​mcp/​McpLegacySessionHost.java Adds a reusable sessionful legacy test server.
org.restlet.ext.mcp/​src/​test/​java/​org/​restlet/​ext/​mcp/​McpLegacyBoundaryTestCase.java Checks legacy dependency boundaries.
org.restlet.ext.mcp/​src/​test/​java/​org/​restlet/​ext/​mcp/​McpHttpTestCase.java Updates HTTP transport and interoperability tests.
org.restlet.ext.mcp/​src/​test/​java/​org/​restlet/​ext/​mcp/​McpDualEraClientTestCase.java Tests detection, fallback, caching and sessions.
org.restlet.ext.mcp/​src/​main/​java/​org/​restlet/​ext/​mcp/​StdioClientHelper.java Binds clients through the legacy seam.
org.restlet.ext.mcp/​src/​main/​java/​org/​restlet/​ext/​mcp/​McpToolFilter.java Introduces modern-type tool filtering.
org.restlet.ext.mcp/​src/​main/​java/​org/​restlet/​ext/​mcp/​McpServerAdapter.java Makes the modern server the shared core.
org.restlet.ext.mcp/​src/​main/​java/​org/​restlet/​ext/​mcp/​McpResourceTemplate.java Renames the shared resource-template registration.
org.restlet.ext.mcp/​src/​main/​java/​org/​restlet/​ext/​mcp/​McpResource.java Renames the shared resource registration.
org.restlet.ext.mcp/​src/​main/​java/​org/​restlet/​ext/​mcp/​McpProtocol.java Adds auto mode and legacy negotiation constants.
org.restlet.ext.mcp/​src/​main/​java/​org/​restlet/​ext/​mcp/​McpInterceptor.java Switches interception to modern message types.
org.restlet.ext.mcp/​src/​main/​java/​org/​restlet/​ext/​mcp/​McpClientAdapter.java Adds era selection, modern results and session options.
org.restlet.ext.mcp/​src/​main/​java/​org/​restlet/​ext/​mcp/​internal/​McpModernHttpTransport.java Preserves HTTP status information for fallback.
org.restlet.ext.mcp/​src/​main/​java/​org/​restlet/​ext/​mcp/​internal/​McpModernFeatures.java Removes legacy registration conversion.
org.restlet.ext.mcp/​src/​main/​java/​org/​restlet/​ext/​mcp/​internal/​McpModernExchange.java Uses modern types throughout interception.
org.restlet.ext.mcp/​src/​main/​java/​org/​restlet/​ext/​mcp/​internal/​McpMessages.java Removes legacy decoding from shared utilities.
org.restlet.ext.mcp/​src/​main/​java/​org/​restlet/​ext/​mcp/​internal/​McpLegacyEdge.java Defines the core-to-legacy seam.
org.restlet.ext.mcp/​src/​main/​java/​org/​restlet/​ext/​mcp/​internal/​McpHttpStatusException.java Represents HTTP failures used in era detection.
org.restlet.ext.mcp/​src/​main/​java/​org/​restlet/​ext/​mcp/​internal/​McpEras.java Caches detected endpoint eras.
org.restlet.ext.mcp/​src/​main/​java/​org/​restlet/​ext/​mcp/​internal/​legacy/​StdioMcpClientTransport.java Isolates legacy stdio and rejects server requests.
org.restlet.ext.mcp/​src/​main/​java/​org/​restlet/​ext/​mcp/​internal/​legacy/​RestletMcpClientTransport.java Isolates legacy HTTP and adds optional sessions.
org.restlet.ext.mcp/​src/​main/​java/​org/​restlet/​ext/​mcp/​internal/​legacy/​McpLegacyServer.java Translates legacy requests and modern results.
org.restlet.ext.mcp/​src/​main/​java/​org/​restlet/​ext/​mcp/​internal/​legacy/​McpLegacyClient.java Wraps the legacy SDK with modern result types.
changes.md Documents the revised architecture and APIs.

Review effort: Balanced

…ssion deletion

Address the first Copilot review:
- A legacy request failing past the edge, in an interceptor or at the
  deadline, is answered with its id once decoded, not a null id that a
  stdio client can't match to its call.
- server/discover failing with a header mismatch or a missing client
  capability error comes from a modern server: auto mode keeps the
  failure instead of falling back to initialize.
- A request a legacy server sends on a response stream is refused with
  -32601 with or without a session, and the refusal's empty 202 is
  accepted.
- Notifications and replies, which open no stream, take workers of
  their own: a refusal, or notifications/initialized, no longer waits
  for the call slot the exchange it follows still holds, so a client
  with one call slot can initialize and refuse.
- The session DELETE on close runs on its own thread and is aborted at
  the request timeout, so a stalled server no longer blocks stop().

Co-Authored-By: Claude Opus 5.5 <[email protected]>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

A delayed 404 from an expired session can invalidate its valid replacement and disrupt concurrent calls, alongside unresolved protocol-recovery, version-tracking, and test-coverage gaps.

1 open issue
5 previously missed issues

In code that hasn't changed since last review

Medium severity Negotiated-version getter remains stale after session expiry

org.restlet.ext.mcp/​src/​main/​java/​org/​restlet/​ext/​mcp/​McpClientAdapter.java:176

After a server-issued legacy session expires, the SDK initializes again and the transport updates its negotiated version from the new response. If that handshake selects a different supported revision, this getter still reports the initial version: both this field and McpLegacyClient.negotiated are snapshots of the first handshake. Read the legacy client's current negotiated version, keeping it synchronized with the transport, and cover a version change after session expiry.

Medium severity Cached startup failures trigger unsafe era rediscovery

org.restlet.ext.mcp/​src/​main/​java/​org/​restlet/​ext/​mcp/​McpClientAdapter.java:331

Any cached startup failure clears the era and retries discovery, including timeouts and HTTP 5xx responses that do not establish a protocol mismatch. This sends extra requests during outages and can repeat a session-creating handshake after an ambiguous timeout. Preserve these failures, re-probe only when the cached era is contradicted, and extend the server-error test to cover cached eras.

Medium severity Protocol mismatch recovery is missing after startup

org.restlet.ext.mcp/​src/​main/​java/​org/​restlet/​ext/​mcp/​McpClientAdapter.java:331

Era recovery runs only during startup: after an auto client starts, all four common operations keep using the selected client even if the endpoint changes eras. The client therefore cannot perform the promised one-time re-probe or correct its cached era when a later operation exposes the mismatch. Add coordinated runtime recovery for protocol mismatches without blindly replaying potentially side-effecting tool calls.

Medium severity Legacy compatibility tests omit earlier advertised revisions

org.restlet.ext.mcp/​src/​main/​java/​org/​restlet/​ext/​mcp/​internal/​legacy/​McpLegacyClient.java:105

The legacy tests negotiate only 2025-11-25, but this now accepts three earlier revisions in auto mode. That behavior also depends on the SDK accepting the negotiated revision and the transport switching subsequent version headers. Add parameterized legacy handshakes for the earlier advertised revisions, assert a subsequent call uses the negotiated header, and cover rejection of an unlisted revision; the current fixtures do not exercise this compatibility expansion.

Medium severity Legacy import boundary test misses wildcard imports

org.restlet.ext.mcp/​src/​test/​java/​org/​restlet/​ext/​mcp/​McpLegacyBoundaryTestCase.java:36

This pattern does not match wildcard imports such as import io.modelcontextprotocol.spec.*; (or the corresponding server.* and client.* imports). Consequently, a core class can import legacy SDK types through a wildcard and still pass onlyTheLegacyPackageUsesTheLegacySdk(), defeating the boundary this test is meant to enforce. Include wildcard imports in the matches while retaining the exact HeaderAccessor exception.

5 resolved since last review

Review effort: Balanced

Address the second Copilot review:
- A delayed 404 for a legacy session already replaced fails its request
  without reporting an expiry to the SDK client, which would otherwise
  initialize again and drop the valid replacement.
- getNegotiatedVersion() follows the handshake made after an expired
  legacy session, which may negotiate another revision.
- An era remembered for an endpoint is probed again only when the
  server contradicts it, with a 4xx or a JSON-RPC error the other era
  would answer with. A timeout or a 5xx is thrown as is and keeps the
  remembered era, so an outage adds no request and an ambiguous
  handshake is not repeated. The legacy transport now reports HTTP
  failures with their status for that.
- A started client keeps its era and replays nothing: the Javadoc now
  says so, instead of promising a later re-probe.
- Tests negotiate each earlier legacy revision, check the header sent
  after it, and refuse one the client doesn't offer.
- The legacy boundary test catches wildcard imports of the legacy SDK.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@jlouvel

jlouvel commented Nov 13, 2026

Copy link
Copy Markdown
Collaborator Author

Answers to the five findings in the body of the second Copilot review (c2350ac):

  1. Negotiated-version getter remains stale after session expiry: fixed in e568ed5. getNegotiatedVersion() now reads the legacy client's current revision, which the transport updates on each initialize. reportsTheRevisionNegotiatedAfterTheSessionExpires expires the session, makes the next handshake negotiate 2025-06-18, and checks both the getter and the header sent after it.
  2. Cached startup failures trigger unsafe era rediscovery: fixed in e568ed5. A cached era is probed again only when the server contradicts it with a 4xx or a JSON-RPC error that the other era would answer with. A timeout or a 5xx is thrown as is and keeps the cached era. The legacy transport now reports HTTP failures with their status, so a 4xx on initialize is recognized. doesNotFallBackOnAServerError now also covers cached modern and legacy eras (one request each, cache kept). probesAgainWhenAModernServerRefusesTheHandshake checks that a 400 on a cached legacy era still leads to the modern client.
  3. Protocol mismatch recovery is missing after startup: declined. A started client keeps its era by design. Switching eras in the middle of a running client would mean replaying requests that may already have run on the server, such as tools/call, and the review rightly warns against that. Recovery happens at the next start instead: with item 2, a start whose cached era the server contradicts forgets that era and probes again. The Javadoc promised a re-probe "if it later proves wrong", which overstated this. In e568ed5 it now says that a started client keeps its era, and that after a server changes era you stop the client and start a new one.
  4. Legacy compatibility tests omit earlier advertised revisions: fixed in e568ed5 (tests only; the code already handled this). negotiatesEachLegacyRevision runs the handshake for each of 2025-11-25, 2025-06-18, 2025-03-26 and 2024-11-05. It checks the reported revision and the MCP-Protocol-Version header of the call that follows. It also checks that a revision the client doesn't offer (2024-10-07) fails the start and caches no era.
  5. Legacy import boundary test misses wildcard imports: fixed in e568ed5. The pattern now ends with [\w.]*[\w*], so spec.*, server.*, client.* and static wildcard imports match. The HeaderAccessor exception still matches exactly. theLegacySdkPatternCatchesWildcardImports covers both cases.

Each fix comes with a regression test that fails without it. Item 4 is coverage only, so its test passes on the old code too. ./mvnw -o -B verify -P default,test -pl org.restlet.ext.mcp -am passes: 122 MCP tests, 0 failures.

@sonarqubecloud

Copy link
Copy Markdown

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Modern requests can expose a legacy protocol version to interceptors and tool filters, and correlated HTTP 503 errors can incorrectly trigger legacy fallback and discard the cached modern era.

No open issues 🎉

2 previously missed issues

In code that hasn't changed since last review

Medium severity Preserve HTTP 5xx status to prevent erroneous legacy fallback

org.restlet.ext.mcp/​src/​main/​java/​org/​restlet/​ext/​mcp/​McpClientAdapter.java:448

An HTTP 503 with a correlated JSON-RPC error such as -32603 loses its HTTP status in McpModernHttpTransport.single() and reaches this branch as an McpException. Auto mode then attempts a legacy handshake and may discard a cached modern era during a transient outage. Preserve the status for classification, avoid fallback on 5xx responses, and extend doesNotFallBackOnAServerError with a correlated JSON error body.

Medium severity Default request context reports legacy protocol for modern dispatch

org.restlet.ext.mcp/​src/​main/​java/​org/​restlet/​ext/​mcp/​McpServerAdapter.java:402

The dispatcher now accepts modern messages, but the one-argument McpRequestContext constructor still defaults to 2025-11-25 (McpRequestContext.java:23–24). Passing that context, as the updated module test does, makes interceptors and tool filters see a modern request as legacy through protocolVersion(), which can select the wrong era-dependent policy. Align the default context with modern dispatch, retaining explicit legacy contexts at the edge, or normalize/validate the revision here.

1 resolved since last review

Review effort: Balanced

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants